Skip to content

perf(ioloader): decode NPY payloads unboxed, and per leading-axis block concurrently - #29

Merged
Robertboy18 merged 2 commits into
lean-dojo:mainfrom
NicolasRouquette:npy-bulk-decode
Oct 23, 2026
Merged

Robertboy18 merged 2 commits into
lean-dojo:mainfrom
NicolasRouquette:npy-bulk-decode

Conversation

@NicolasRouquette

@NicolasRouquette NicolasRouquette commented Aug 19, 2026 •

Copy link
Copy Markdown
Contributor

Body

What

The .npy element loop allocates on every step: an Option per byte inside readLittleEndian,
an Except per element from readNpyElement, and a heap cell per element again to store the
Float in an Array Float. It also re-tests the dtype string once per element.

This selects the decoder once per file inside decodeNpyPayload, reads the bytes without an
intermediate Option, and decodes into a FloatArray. NpyData.values becomes a FloatArray:
the payload is bulk numeric data, so it costs one machine word per element instead of a word
plus a heap cell, and Tensor.from wraps that buffer into a tensor without copying it. Header
validation, the bounded handle reads, prefix validation, and the truncation checks are unchanged;
the in-memory and file-backed readers share the one decoder.

It also adds parseNpyBlocks and readNpyBlocks, which return one
FloatArray per index along the leading axis. Those blocks are physically contiguous in a
C-order payload, so they are independent and can be decoded concurrently. Callers that want the
blocks apart anyway (one channel, one band, one batch element) get the concurrency for free.
Callers that want a single flat payload keep using parseNpy / readNpy, which stay sequential:
producing one contiguous result concurrently would mean filling several arrays and then copying
them all into one, and that copy walks every element a second time for several times the cost of
the concurrency it was meant to buy.

Parallelism and resolveTaskCount make the task count explicit rather than tying it to the
file's shape. The count follows the machine and the block count follows the file, and those are
independent, so blocks are grouped across tasks rather than handed out one per task: a
(50000, 1, 28, 28) dataset does not spawn 50 000 tasks.

Measured

Measured on the Lean v4.34.0 tree, before this rebase onto 4.35 main; the decoder is unchanged by
the rebase apart from the names of the block readers. One 764 MB <f8 file of shape
(8, 3456, 3456) (95 551 488 elements), already in memory, Xeon w7-3465X (28 cores, 56 threads).
One binary holds all three decoders; the first row is the element loop main ships at b062b9a3,
copied verbatim. Five trials per row, each in its own process so peak RSS is per path; the rate is
file bytes over the median; every trial passes a distinct tag so the pure decode cannot be shared
across trials. Peak RSS includes the 764 MB input held in memory.

path median min / max rate peak RSS
element loop on main 13670 ms 13619 / 13710 ms 55 MB/s 2930 MiB
parseNpy, unboxed sequential 1238 ms 1233 / 1315 ms 617 MB/s 1470 MiB
blocks, .sequential 1243 ms 1218 / 1311 ms 614 MB/s 1476 MiB
blocks, .tasks 8 160 ms 159 / 171 ms 4755 MB/s 1558 MiB
blocks, .auto 159 ms 157 / 168 ms 4780 MB/s 1624 MiB

.auto resolves to 8 tasks here: the leading dimension is 8, and resolveTaskCount never
splits a block, so the file's leading axis is the ceiling on this decode's parallelism whatever
the core count.

Tests

NN/Tests/Data/IO/Npy.lean synthesises .npy files in memory, so the expected values are
known exactly, and writes the same bytes to .lake/build/tmp for the file-backed readers.
It checks:

  • every element survives the round trip bit for bit, for <f8 and <f4, scalars included.
    == on Float would be the wrong test: it identifies 0.0 with -0.0 and makes every
    NaN unequal to itself, so it would both miss sign-bit transcription errors and reject a
    correctly decoded NaN;
  • the patterns == on Float cannot see, through every path (parseNpy, the blocks, readNpy,
    readNpyBlocks): both signed zeros, both infinities and the extremes of either
    format decode to exactly their stored bits; a NaN decodes as the canonical quiet NaN whatever
    its sign or payload, because Lean's Float.ofBits and Float32.ofBits canonicalize every NaN
    pattern (Float.Model.Format.Valid admits one NaN encoding). The test pins that result and the
    loader's docstring states it;
  • the leading-axis blocks concatenate back to exactly what parseNpy returns, for 1-D, 2-D,
    and 3-D shapes and for zero-sized axes on either side;
  • every task count returns the same bytes: a sweep over requested counts including values
    above the block count and above the core count, plus sequential and auto. This is what
    makes the task count a free tuning knob rather than part of the contract;
  • repeated concurrent decodes agree, so nothing depends on the order tasks finish;
  • resolveTaskCount respects each of its three bounds;
  • Fortran-order payloads are still reordered into C order;
  • malformed files are rejected: bad magic, truncated payload (flat and blocks), unsupported
    dtype, Fortran order and scalar shape for the block path;
  • readNpy and readNpyBlocks agree bit for bit with their in-memory counterparts,
    and the file-backed block reader rejects a Fortran-order or scalar header without reading a
    payload (the fixture is a bare header, and the error names the storage order, not truncation).

It is the only NPY test in the curated suite: main no longer has NN/Tests/API/Data.lean.

Note on Task.spawn

The block decode uses Task.spawn, which takes the work as a Unit → α closure. That is
load-bearing and there is a comment saying so: handing a pure expression to an IO-level spawn
instead lets the compiler float it out of the closure onto the spawning thread, and the fan-out
then runs sequentially at exactly the single-threaded rate while still looking parallel.

Compatibility

NpyData.values changes type from Array Float to FloatArray. In-tree consumer:
NN/API/Data/Sources.lean (now Tensor.from on the buffer). NN/Examples/Data/Loaders/Npy.lean
reads only dtype and shape.

Checks run

On 641461c1 (two commits on main 72754452, Lean v4.35.0), compiled suite through the test
driver:

lake -R -Kcuda=false build NN NNTests nn_tests_suite NNCI NNExamples
  Build completed successfully (10204 jobs).
lake -R -Kcuda=false test
  -- npy loader --
  npy loader: ok
  == TorchLean: all curated tests passed ==
python3 scripts/checks/repo_lint.py --fail-on-warn
  OK: no issues found.

@Robertboy18

Copy link
Copy Markdown
Member

Thanks, Nicolas. We still want to merge this performance work, but the data-loader cleanup in df1612d moved the NPY implementation to NN/Data/IO/Npy.lean and changed the surrounding tensor, shape, and test APIs, so this branch now conflicts.

Could you rebase onto the current main and adapt the implementation and tests to the new layout? Please keep the unboxed FloatArray decoder, the once-per-file decoder selection, the leading-axis block API, and the bit-for-bit concurrency tests. The consumers in NN/API/Data/Sources.lean also need to retain the memory benefit rather than immediately rebuilding the entire payload as a boxed array.

After the rebase, please rerun lake build NN NNCI NNExamples NNTests NNSlowProofs TorchLeanDocs and lake test. Once those pass, this should be ready to merge.

@Robertboy18

Robertboy18 commented Sep 17, 2026 •

Copy link
Copy Markdown
Member

Hey Nicolas, can you rebase this onto the latest main?

@Robertboy18

Copy link
Copy Markdown
Member

Thanks! The NPY decoding improvements still look useful. Could you rebase this onto current main and update the tests for the current loader, preserving its input validation?

@NicolasRouquette

Copy link
Copy Markdown
Contributor Author

Rebased onto current main (b062b9a3) and rewritten against the current loader.

What changed in the port:

  • The decoder now lives inside the loader's own decodeNpyPayload, selected once per file, so
    every header check, the bounded handle reads in readNpyBytes, the prefix validation, and the
    truncation checks are exactly as they are on main. readNpy, readNpyLeadingAxisPrefix,
    and parseNpy keep their signatures; only NpyData.values changes type, to FloatArray.
  • Your August note about the consumers is addressed: NN/API/Data/Sources.lean now wraps the
    unboxed payload into the tensor through Tensor.from on the FloatArray, which is a rewrap
    of the same buffer, not a boxed rebuild. Internal.tensorFromFlat stays for the CSV paths.
  • The block API gained a file-backed reader, readNpyLeadingAxisBlocks, that validates the
    header for block loading before reading the payload and then uses the same bounded reads as
    readNpy.
  • Tests: NN/Tests/Data/IO/Npy.lean covers the in-memory and file-backed paths (bit-for-bit
    round trips for both dtypes, every task count returning the same blocks, determinism across
    repeated concurrent decodes, the task-count bounds, Fortran reordering, scalars, zero-sized
    axes, and rejections). NN/Tests/API/Data.lean only changed where it compares the payload,
    which now goes through .values.data.

Re-measured on this tree, same binary for all rows (details in the description):

path median of 5 rate peak RSS
element loop on main today 13670 ms 55 MB/s 2930 MiB
parseNpy, unboxed sequential 1238 ms 617 MB/s 1470 MiB
leading-axis blocks, 8 tasks 160 ms 4755 MB/s 1558 MiB

Checks on 181edd40:

lake build NN NNTests nn_tests_suite     Build completed successfully (10947 jobs).
lake build NNCI NNExamples               Build completed successfully.
python3 scripts/checks/repo_lint.py      OK: no issues found.
lake exe nn_tests_suite                  NPY metadata and prefix IO: passed
                                         npy loader: ok
                                         == TorchLean: all curated tests passed ==

The PR description is updated to match.

NicolasRouquette added a commit to NicolasRouquette/TorchLean that referenced this pull request Oct 3, 2026
Brings in the upstream PR branch (lean-dojo#29) at 181edd4,
rebased onto upstream main b062b9a.

combined is rebuilt from b062b9a, the first upstream tip on the LibTorch
backend (csrc/cuda is gone). Its previous tip d162950 (Lean 4.33, custom
CUDA kernels, arena, cache cap, TexTable) is kept as bkp/pre-libtorch/combined
and is not merged: nothing on it applies to the LibTorch tree.
@Robertboy18

Copy link
Copy Markdown
Member

Hey Nicolas, thanks! This looks like a useful improvement. The loader, its tests, and the updated data API compile locally. My isolated interpreter run could not load the native parseNpy implementation, so I have not confirmed the runtime suite locally yet; that is a scratch-run setup limitation, not evidence of a bug in this PR. Could you share the native runtime test command and results? It would also be good to cover signed zero, infinities, and NaNs explicitly in the decode tests. Keeping this open until the runtime check is confirmed.

…ck concurrently

The `.npy` element loop allocated on every step: an `Option` per byte, an `Except` per element,
and a heap cell per element to box the `Float`. It also re-tested the dtype once per element.

`decodeNpyPayload` now selects a range decoder once per file and fills a `FloatArray`, so
`NpyData.values` is unboxed. `NN.API.Data.Sources` wraps that buffer into a tensor through
`Tensor.from` without a copy or a boxed intermediate. Header validation, bounded handle reads, and
the truncation checks are unchanged; the in-memory and file-backed readers share one decoder.

`parseNpyBlocks` and `readNpyBlocks` return one `FloatArray` per index along the leading axis of
a C-order array and decode those blocks concurrently. `Parallelism` and `resolveTaskCount` bound
the task count by the request, the block count, and the payload size.

`NN/Tests/Data/IO/Npy.lean` checks bit-for-bit round trips for both dtypes, that every task count
returns the same blocks, determinism across repeated concurrent decodes, the task-count bounds,
Fortran reordering, rejections, and that the file-backed readers agree with the in-memory ones.
… NPY decode path

The round-trip tests draw their values from a smooth sample, so none of them is a signed zero, an
infinity or a NaN, and `==` on `Float` could not have told the first two apart or accepted the
third. `testSpecialValues` stores those bit patterns, with NaN payloads and the extremes of either
format, as `<f8` and `<f4` files and checks the flat, block and file-backed decoders.

Signed zeros, subnormals, infinities and every finite value decode to exactly their stored bits.
A NaN decodes as a NaN, but not as its stored bits: Lean's `Float` is logically `Float.Model`, a
subtype of `UInt64` whose `Format.Valid` admits one NaN pattern, and `Float.ofBits` and
`Float32.ofBits` return that canonical NaN for every NaN input, sign and payload included, so no
decoder over `Float` can preserve them. The test pins the canonical result, and the loader's
documentation says so.
@NicolasRouquette

Copy link
Copy Markdown
Contributor Author

Rebased onto current main (72754452, Lean 4.35); the tip is 641461c1, two commits: the
change, and the review commit for your two points.

The rebase changed two things, and the decoder itself is otherwise line for line what you
reviewed:

  • main renamed the prefix readers to readNpyPrefix / parseNpyPrefix, so the block readers
    this PR adds follow the same naming: readNpyBlocks / parseNpyBlocks (they were
    readNpyLeadingAxisBlocks / parseNpyLeadingAxisBlocks).
  • main removed NN/Tests/API/Data.lean, and with it this PR's adjustment of its comparisons to
    .values.data. NN/Tests/Data/IO/Npy.lean is now the only NPY test in the curated suite.

Native runtime command and results. The suite is the compiled nn_tests_suite executable,
the test driver that lake test builds and runs (the CI's scripts/lake.sh -Kcuda=false test
step); it is not meant to run in the interpreter. On 641461c1:

lake -R -Kcuda=false build NN NNTests nn_tests_suite NNCI NNExamples
  Build completed successfully (10204 jobs).
lake -R -Kcuda=false test
  == TorchLean: curated tests ==
  -- npy loader --
  npy loader: ok
  == TorchLean: all curated tests passed ==
python3 scripts/checks/repo_lint.py --fail-on-warn
  OK: no issues found.

Signed zero, infinities, NaNs. testSpecialValues in NN/Tests/Data/IO/Npy.lean stores
twelve bit patterns as <f8 and <f4 files of shape (3, 4), so the block path cuts them into
three blocks: both signed zeros, both infinities, quiet NaNs of either sign, NaNs with a payload
of either sign, and the largest finite and smallest subnormal magnitudes of either sign. It checks
parseNpy, parseNpyBlocks, readNpy and readNpyBlocks against the stored bits for <f8 and
against the widened Float32 for <f4, where it also checks the decoded values themselves: the
sign of each zero, the exact infinities, the magnitude of the extremes, and NaN-ness.

Writing it found something that belongs in the loader's contract. Signed zeros, subnormals,
infinities and every finite value decode to exactly their stored bits. A NaN decodes as a NaN,
but not as its stored bits: Float.ofBits and Float32.ofBits return the canonical quiet NaN for
every NaN pattern, sign and payload included. That is the runtime, not the loader: Float is
logically Float.Model, a subtype of UInt64 whose validity predicate says "By 'valid' we mean
that NaN is encoded using the canonical NaN" (Init/Data/Float/Model/Format/Valid.lean), and
lean_float_of_bits enforces it. Compiled probe on v4.35.0:

for w in [0x7ff8000000000000, 0xfff8000000000000, 0x7ff00000deadbeef, 0xfff00000deadbeef] do
  let f := Float.ofBits w
  IO.println s!"in={w} toBits={f.toBits} isNaN={f.isNaN}"
for w in [0x7fc00000, 0xffc00000, 0x7f80beef, 0xff80beef] do
  let g := Float32.ofBits w
  IO.println s!"f32 in={w} toBits={g.toBits} widened={g.toFloat.toBits} isNaN={g.isNaN}"
in=9221120237041090560 toBits=9221120237041090560 isNaN=true
in=18444492273895866368 toBits=9221120237041090560 isNaN=true
in=9218868440963333871 toBits=9221120237041090560 isNaN=true
in=18442240477818109679 toBits=9221120237041090560 isNaN=true
f32 in=2143289344 toBits=2143289344 widened=9221120237041090560 isNaN=true
f32 in=4290772992 toBits=2143289344 widened=9221120237041090560 isNaN=true
f32 in=2139143919 toBits=2143289344 widened=9221120237041090560 isNaN=true
f32 in=4286627567 toBits=2143289344 widened=9221120237041090560 isNaN=true

So no decoder over Float can preserve a NaN's sign or payload. The test pins the canonical
result, so a change in the runtime would show up in this suite, and the decoder's docstring now
says it. The PR description is updated to match.

@Robertboy18
Robertboy18 merged commit 12b613b into lean-dojo:main Oct 23, 2026
2 checks passed
@NicolasRouquette
NicolasRouquette deleted the npy-bulk-decode branch October 23, 2026 18:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants